fix(deploy): prevent code-generation injection from angular.json values - #3739
fix(deploy): prevent code-generation injection from angular.json values#3739herdiyana256 wants to merge 1 commit into
Conversation
armando-navarro
left a comment
There was a problem hiding this comment.
Thanks for this, and for keeping at the ng deploy hardening. I reproduced what you describe: rendering the generated function with a crafted server outputPath gives a standalone require('child_process').execSync(...) statement in index.js, and a crafted functionsNodeVersion adds its own RUN line to the Dockerfile.
There is one gap I think should be filled before this merges, and a few smaller notes that should not hold it up.
Blocking: two more angular.json values still reach the generated function unguarded
functionName and region come from the same deploy options block as functionsNodeVersion, and both are written straight into the same generated index.js with no validation:
functionNameis written as a bare identifier:exports.${functionName || DEFAULT_FUNCTION_NAME}sits infunctions-templates.tsat lines 44 and 62, so this applies to both the default and the CF3v2 template.- A value of
ssr; require('child_process').execSync('...'); var _xrenders asexports.ssr;followed by the injected call, with the template's own=assignment becoming that variable's initializer. - The file still parses, so the injected call runs when the function loads.
regionis written inside a quoted string:.region('${options.region || DEFAULT_FUNCTION_REGION}')at line 45 puts it in a single-quoted literal in the default template, so a'breaks out exactly the wayoutputPathdid.- The CF3v2 template passes
regionthroughJSON.stringify, so that path is already safe.
Neither has a pattern in schema.json (functionName and region are both plain type: string), so nothing upstream constrains them either.
Since the description says this prevents code-generation injection from angular.json values, either of these would unblock it for me:
- Extend the fix with a check on
functionNameand one onregion, called before the template runs, matching the pattern you already established. This is the outcome I would prefer. - Narrow the title and description to the two values this covers, and open a short follow-up issue for
functionNameandregion, so the change matches its claim and the remaining exposure stays tracked rather than closed over.
Non-blocking notes
- Consider escaping where the value is written rather than only screening it on the way in. Your validators are a blocklist of dangerous characters, which has to stay ahead of every context the value lands in. Two places that could be structural instead:
- For a string position, emit
${JSON.stringify(value)}and drop the quotes already in the template, sinceJSON.stringifysupplies its own. Written as.region(${JSON.stringify(...)})it escapes correctly, whereas leaving the existing quotes in place would yield.region('"us-central1"')and change the value. - For the
exports.<name>position, an allowlist of valid JavaScript identifiers is easier to reason about than a list of rejects. It would also make a currently silent failure loud: afunctionNamecontaining a dash already generates a file that does not parse.
- For a string position, emit
- A leading dash still gets through.
- The character list does not reject
-, and on the Cloud Run path the generatedpackage.jsonsetsstart: node <serverOutputPath>/main.js. - So a server
outputPathbeginning with-reachesnodeas a flag rather than a path. - The comment above the check says a legitimate output directory never contains these characters, which reads stronger than what the character class enforces.
- The character list does not reject
- Nothing fails if the checks stop being called.
- The new specs exercise
assertSafeOutputPathandassertSafeNodeVersiondirectly. - What I could not find is a test that fails if the builder stops calling them: removing the calls from
deployToFunctionanddeployToCloudRunstill passes the whole suite. - A test that drives one of those functions with a hostile
outputPathand expects a throw would keep the protection from quietly disappearing later.
- The new specs exercise
- Spec count in the description.
- Locally
npm run test:nodereports 78 specs on this branch, not the 150 in the body. - The branch is based on an older commit, so a rebase on current main would refresh that number.
- Locally
If I have misread any of this, point me at it and I will take another look.
deployToFunction / deployToCloudRun interpolate several angular.json-derived
values straight into generated, later-executed artifacts. A server build
target's outputPath is written raw into the generated Cloud Function index.js
(`require('./<outputPath>/main')`) and into the generated package.json start
script (`node <outputPath>/main.js`); functionName is written raw as the
`exports.<name>` target in index.js; region is written into a quoted string in
the default index.js template; and functionsNodeVersion is written raw into the
generated Cloud Run Dockerfile (`FROM node:<version>-slim`). A malicious or
cloned workspace could therefore run arbitrary code in the deployed
function/container (and locally during `firebase serve` preview) via
`ng deploy`. These sinks are distinct from the gcloud argv path (PR angular#3726) and
the execSync sinks (PR angular#3738).
Validate outputPath (assertSafeOutputPath, now also rejecting a leading dash
that node would read as a flag in the start script), functionName
(assertSafeFunctionName, a plain-identifier allowlist), and functionsNodeVersion
(assertSafeNodeVersion) before they reach code generation, and emit region
through JSON.stringify in the default template so it is structurally escaped
rather than screened. Add schema patterns for functionName, region and
functionsNodeVersion. Tests cover the validators directly and drive
deployToFunction / deployToCloudRun with hostile inputs so the checks cannot be
dropped without a failing spec.
6333899 to
4abf2eb
Compare
|
Thanks for the careful review, and for reproducing both sinks. I took the outcome you preferred and closed the Blocking:
Non-blocking notes
Also added
|
The SSR deploy builders interpolate several
angular.json-derived values straight into generated artifacts that are later executed.A server build target's
outputPath(read viagetTargetOptions) is written raw into the generated Cloud Functionindex.jsasrequire('./${path}/main')and into the generatedpackage.jsonstart script asnode ${path}/main.js.functionsNodeVersionis written raw into the generated Cloud RunDockerfileasFROM node:${version}-slim. None of these has any validation. A crafted serveroutputPathsuch asx').app(); require('child_process').execSync('...'); ('lands as a standalone statement inindex.jsand runs on every Cloud Function cold start (and locally duringfirebase servepreview); a craftedfunctionsNodeVersioninjects extraRUNinstructions executed during the Cloud Run container build. Reachable the moment a developer runsng deployon a malicious or cloned workspace. These are distinct sinks from the gcloud argv path and theexecSynccalls addressed separately.The fix validates each build target's
outputPath(assertSafeOutputPath) andfunctionsNodeVersion(assertSafeNodeVersion) before they reach code generation, rejecting values that carry quotes, newlines, or shell metacharacters, and adds afunctionsNodeVersionschemapattern. Unit tests cover both validators.npm run test:nodepasses (150 specs, 0 failures); lint and typecheck clean.